from utils.misc import INF
from utils.namespace import Namespace
from utils.timeseries import TimeSeries
from utils.prettyrepr import prettify_class

from .infeasible import Infeasible
from .signals import SIGNALS


@prettify_class
class Solution(object):
    """This class is meant as a standard container for solution data of any optimization problem.
    It contains two attributes, which are the actual solution data and a metadata namespace,
    containing information such as the objective function value, cpu time, and any other relevant
    information."""
    def __init__(self, sol_data, objective, cpu=None, **meta):
        self.data = sol_data
        self.meta = Namespace(objective=objective, cpu=cpu, **meta)

    def get_objective(self):
        return self.meta.objective

    def get_cpu(self):
        return self.meta.cpu

    def is_feasible(self):
        return not self.is_infeasible()

    def is_infeasible(self):
        return isinstance(self.meta.objective, Infeasible)

    def __info__(self):
        return "objective={!r}, cpu={:.02f}".format(self.meta.objective, self.meta.cpu)

    def __cmp__(self, other):
        return cmp(self.meta.objective, other.meta.objective)


class SolutionList(list):
    """
    The SolutionList class provides an easy standard way to record solutions found by solvers
    and plot a time series of the objective function value of the solutions.
    """
    def add(self, sol_data, objective, cpu=None, **meta):
        """Creates a new Solution object and appends it to the list. The new Solution object is
        returned."""
        sol = Solution(sol_data, objective, cpu, **meta)
        list.append(self, sol)
        return sol

    def clear(self):
        """Remove all items from the list."""
        del self[:]

    def get_feasible(self):
        """Get only the feasible solutions in the list."""
        return filter(Solution.is_feasible, self)

    def get_infeasible(self):
        """Get only the infeasible solutions in the list."""
        return filter(Solution.is_infeasible, self)

    def plot(self, axes=None, extend_to=None, filename=None, dpi=300, **kwargs):
        """Build and display (or save) a plot showing the solutions' objective value over time.
        Note that this requires CPU time to be recorded. If filename is None, the plot is
        displayed to the screen, otherwise it is saved to the specified file. The filename
        extension will determine the format of the output file (e.g. pdf, eps, png, jpg)."""
        tseries = SolutionList.make_tseries(self)
        return tseries.plot(axes=axes, xlabel="CPU time (s)", ylabel="Objective",
                            filename=filename, dpi=dpi, **kwargs)

    @staticmethod
    def make_tseries(solutions, extend_to=None):
        """Given an iterable of solutions, creates a time series of objective function values
        over cpu time.  If 'extend_to' is not None, the time series' last value is repeated at
        t = 'extend_to'."""
        tseries = TimeSeries.from_iterable((sol.meta.cpu, sol.meta.objective) for sol in solutions)
        if extend_to is not None and extend_to > tseries.time:
            tseries.append(extend_to, tseries.value)
        return tseries


class SolverSolutionList(SolutionList):
    """A solution list attached to a solver. It uses some of the solver's attributes and methods
    to compute missing information (objective value and cpu time). This also updates the solver's
    incumbent value, and keeps track of the best and worst feasible and infeasible values found
    so far."""
    def __init__(self, solver):
        SolutionList.__init__(self)
        self.solver = solver            # reference to the solver owning this list
        self.checked_sol = None         # temporarily stores the solution being checked
        self.checked_obj = None         # same as checked_sol, but for objective value
        self.feas_count = 0             # number of feasible solutions checked
        self.infeas_count = 0           # number of infeasible solutions checked
        self.best_feas_value = None     # best feasible value found so far
        self.worst_feas_value = None    # worst feasible value found so far
        self.least_infeas_value = None  # least infeasible value found so far
        self.most_infeas_value = None   # most infeasible value found so far
        self.best_value = None          # best value so far (best feasible or least infeasible)
        solver.channel.listen(SIGNALS.SOLVER_INITIALIZED, self.init)

    def clear(self):
        SolutionList.clear(self)
        self.feas_count = 0
        self.infeas_count = 0
        self.best_feas_value = None
        self.worst_feas_value = None
        self.least_infeas_value = INF
        self.most_infeas_value = 0.0
        self.best_value = Infeasible(INF)

    def init(self, listener=None):
        """These two attributes can only be initialized once the solver is in the 'initialized'
        state, otherwise the solver's incumbent and bound are undefined."""
        self.best_feas_value = self.solver.incumbent
        self.worst_feas_value = self.solver.bound

    def best(self):
        """Returns the best solution in the list, or None if it's empty. Note that this method
        uses the solver's objective function sense to determine which is the best solution."""
        if len(self) == 0:
            return None
        feasible_sols = self.get_feasible()
        if len(feasible_sols) > 0:
            best_in = self.solver.objective.sense.best_in
            candidates = feasible_sols
        else:
            best_in = min
            candidates = self
        return best_in(candidates, key=Solution.get_objective)

    def check(self, sol_container, objective=None, cpu=None, **meta):
        """Check a given solution's objective function value. If it is better than the current
        incumbent, the solution is added to the solver's solution list. This method returns a
        Solution object if the incumbent was updated, otherwise it returns None."""
        solver = self.solver
        if objective is None:
            objective = solver.objective.function(sol_container)
        if cpu is None:
            cpu = solver.cpu.total
        self.checked_sol = sol_container
        self.checked_obj = objective
        self.solver.channel.emit(SIGNALS.CHECKING_SOLUTION)
        if isinstance(objective, Infeasible):
            if objective < self.least_infeas_value or objective > self.most_infeas_value:
                return self.add(sol_container, objective, cpu, **meta)
        else:
            is_better = solver.objective.sense.is_better
            if (is_better(objective, self.best_feas_value) or
               is_better(self.worst_feas_value, objective)):
                return self.add(sol_container, objective, cpu, **meta)
        return None

    def add(self, sol_container, objective=None, cpu=None, **meta):
        """Add a solution to the solution list, and update the incumbent value if necessary. This
        method creates and returns a Solution object."""
        # compute missing information (objective and cpu time)
        solver = self.solver
        if objective is None:
            objective = solver.objective.function(sol_container)
        if cpu is None:
            cpu = solver.cpu.total
        # update best and worst infeasible values
        if isinstance(objective, Infeasible):
            self.infeas_count += 1
            if objective < self.least_infeas_value:
                self.least_infeas_value = objective
                solver.channel.emit(SIGNALS.LEAST_INFEAS_VALUE_CHANGED)
                if self.feas_count == 0:
                    self.best_value = objective
                    solver.channel.emit(SIGNALS.BEST_SOL_VALUE_CHANGED)
            if objective > self.most_infeas_value:
                self.most_infeas_value = objective
                solver.channel.emit(SIGNALS.MOST_INFEAS_VALUE_CHANGED)
        # update best and worst feasible values
        else:
            self.feas_count += 1
            is_better = solver.objective.sense.is_better
            if is_better(objective, self.best_feas_value):
                self.best_feas_value = objective
                solver.channel.emit(SIGNALS.BEST_FEAS_VALUE_CHANGED)
                self.best_value = objective
                solver.channel.emit(SIGNALS.BEST_SOL_VALUE_CHANGED)
            if is_better(self.worst_feas_value, objective):
                self.worst_feas_value = objective
                solver.channel.emit(SIGNALS.WORST_FEAS_VALUE_CHANGED)
            # update the solver's incumbent (if applicable)
            if is_better(objective, solver.incumbent):
                solver.incumbent = objective
        # extract the solution data and metadata and build a Solution object
        sol_data, sol_meta = solver._extract_solution_and_meta(sol_container)
        sol = SolutionList.add(self, sol_data, objective, cpu)
        sol.meta.update(index=len(self), iteration=solver.iters.total+1)
        sol.meta.update(solver.meta)
        sol.meta.update(sol_meta)
        sol.meta.update(meta)
        solver.channel.emit(SIGNALS.SOLUTION_ADDED)
        return sol

    def plot(self, axes=None, seqs=["best_feas_seq"], extend_to=None,
             filename=None, dpi=300, **kwargs):
        if extend_to is None:
            extend_to = self.solver.cpu.total
        axes = None
        for seq in seqs:
            seq = getattr(self, seq)
            tseries = SolutionList.make_tseries(seq(), extend_to=extend_to)
            axes = tseries.plot(axes=axes, xlabel="CPU time (s)", ylabel="Objective",
                                filename=filename, dpi=dpi, **kwargs)
        return axes

    def best_feas_seq(self):
        """Produces a sequence of solutions that caused the best feasible solution value to be
        updated."""
        is_better = self.solver.objective.sense.is_better
        best = self.solver.objective.sense.worst_value
        for sol in self:
            if sol.is_feasible and is_better(sol.meta.objective, best):
                best = sol.meta.objective
                yield sol

    def worst_feas_seq(self):
        """Produces a sequence of solutions that caused the worst feasible solution value to be
        updated."""
        is_better = self.solver.objective.sense.is_better
        worst = self.solver.objective.sense.best_value
        for sol in self:
            if sol.is_feasible and is_better(worst, sol.meta.objective):
                worst = sol.meta.objective
                yield sol

    def least_infeas_seq(self):
        """Produces a sequence of solutions that caused the least infeasible solution value to be
        updated."""
        best = INF
        for sol in self:
            if sol.is_infeasible and sol.meta.objective < best:
                best = sol.meta.objective
                yield sol

    def most_infeas_seq(self):
        """Produces a sequence of solutions that caused the most infeasible solution value to be
        updated."""
        worst = 0.0
        for sol in self:
            if sol.is_infeasible and sol.meta.objective > worst:
                worst = sol.meta.objective
                yield sol
